import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { BrainerceError } from 'brainerce'; import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server'; import { getRegionId } from '@/core/lib/region.server'; import { buildMetaDescription } from '@/core/lib/seo'; import { sanitizeHtml } from '@/core/lib/sanitize'; import { decodeSlug } from '@/core/lib/utils'; import { CategoryJsonLd } from '@/components/seo/category-json-ld'; import { getCanonicalSiteUrl } from '@/core/lib/site-url'; import { Breadcrumbs } from '@/components/seo/breadcrumbs'; import { ProductGrid } from '@/ui/product/product-grid'; type Props = { params: Promise<{ slug: string; locale?: string }>; }; /** * Category (collection) landing page — the highest-leverage organic-SEO * surface: it ranks for broad "research intent" queries ("running shoes") * that individual product pages never capture. Merchant-authored copy (from * the dashboard SEO hub) renders BELOW the grid so products stay above the * fold, and CollectionPage + BreadcrumbList structured data feeds rich * results + AI answers. Products come from getProducts, so this page is one * metadata read + one listing read. */ export async function generateMetadata({ params }: Props): Promise { const { slug: rawSlug, locale } = await params; const slug = decodeSlug(rawSlug); try { const [category, storeInfo] = await Promise.all([ (await getServerClient(locale)).getCategoryBySlug(slug), fetchStoreInfo(locale), ]); const description = category.metaDescription || buildMetaDescription(category.description) || category.name; const canonicalSlug = category.slug || slug; const canonicalPath = locale ? `/${locale}/category/${canonicalSlug}` : `/category/${canonicalSlug}`; // hreflang: category URLs use the same slug across locales (only the // locale prefix changes) — the same convention getCategorySitemapEntries // emits, so the alternates always match real, crawlable URLs. const supportedLocales = storeInfo?.i18n?.supportedLocales ?? []; const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? ''; const baseUrl = await getCanonicalSiteUrl(); const hreflangLanguages = supportedLocales.length > 1 ? Object.fromEntries([ ...supportedLocales.map((loc) => [ loc, loc === defaultLoc ? `${baseUrl}/category/${canonicalSlug}` : `${baseUrl}/${loc}/category/${canonicalSlug}`, ]), ['x-default', `${baseUrl}/category/${canonicalSlug}`], ]) : undefined; return { title: category.name, description, alternates: { canonical: canonicalPath, ...(hreflangLanguages ? { languages: hreflangLanguages } : {}), }, openGraph: { title: category.name, description, type: 'website', images: category.image ? [{ url: category.image, alt: category.name }] : [], }, twitter: { card: 'summary_large_image', title: category.name, description, images: category.image ? [category.image] : [], }, }; } catch (err) { if (err instanceof BrainerceError && err.statusCode === 404) { return { title: 'Category not found' }; } // Anything else (channel origin rejection, network failure) hits the page // render below too and surfaces there — don't stamp a misleading title. return {}; } } export default async function CategoryPage({ params }: Props) { const { slug: rawSlug, locale } = await params; const slug = decodeSlug(rawSlug); const client = await getServerClient(locale); // Only a genuine 404 becomes a storefront 404; a 403 from the channel's // origin check or a network failure must surface as the error it is, not // masquerade as a missing category (or an empty one). const category = await client.getCategoryBySlug(slug).catch((err: unknown) => { if (err instanceof BrainerceError && err.statusCode === 404) return null; throw err; }); if (!category) notFound(); // The listing endpoint owns pagination/FX/publish gating — reuse it rather // than duplicating a product query here. // `regionId` on the listing read too: a category grid that prices in the // default region while the product page prices in the buyer's is worse than // neither doing it. Resolves to undefined on a single-region store, which // makes this call byte-identical to what it was. const { data: products } = await client.getProducts({ categories: [category.id], limit: 48, regionId: await getRegionId(), }); const baseUrl = await getCanonicalSiteUrl(); const url = `${baseUrl}/category/${category.slug || slug}`; const localePrefix = locale ? `/${locale}` : ''; const breadcrumbItems = [ { name: 'Home', href: `${localePrefix}/` }, ...category.breadcrumb .filter((ancestor) => ancestor.slug) .map((ancestor) => ({ name: ancestor.name, href: `${localePrefix}/category/${ancestor.slug}`, })), { name: category.name }, ]; return ( <>

{category.name}

{category.description ? ( // Security: merchant-authored category HTML, sanitized via // `sanitizeHtml` (isomorphic-dompurify) before injecting.
) : null}
); }